<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <title>Arcane Skirmish — 像素风强化版 · 迷你RPG</title>

  <!-- Tailwind CDN -->
  <script src="https://cdn.tailwindcss.com"></script>

  <!-- Google Fonts -->
  <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600;700&family=Inter:wght@300;400;600&display=swap" rel="stylesheet">

  <style>
    :root {
      --glass-bg: rgba(255,255,255,0.04);
      --glass-border: rgba(255,255,255,0.07);
    }
    html,body { height:100%; }
    body {
      font-family: "Poppins", "Inter", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial;
      -webkit-font-smoothing:antialiased;
      -moz-osx-font-smoothing:grayscale;
      background: radial-gradient(circle at 10% 20%, rgba(124,58,237,0.12), transparent 10%),
                  linear-gradient(180deg,#050816 0%, #071028 45%, #04111a 100%);
      color: #e6eef8;
    }

    .glass {
      background: var(--glass-bg);
      border: 1px solid var(--glass-border);
      backdrop-filter: blur(8px) saturate(140%);
      -webkit-backdrop-filter: blur(8px) saturate(140%);
      box-shadow: 0 6px 30px rgba(2,6,23,0.55), inset 0 1px 0 rgba(255,255,255,0.02);
      border-radius: 12px;
    }

    .fancy-title {
      background: linear-gradient(90deg,#a5b4fc 0%, #60a5fa 30%, #fda4af 55%, #ffedd5 80%);
      -webkit-background-clip: text;
      background-clip: text;
      color: transparent;
      font-weight:700;
      letter-spacing: -0.5px;
    }

    .cd-overlay {
      position: absolute;
      inset: 0;
      border-radius: 10px;
      pointer-events: none;
      background: conic-gradient(rgba(0,0,0,0.58) 0deg, transparent 0deg);
      transition: background 80ms linear;
    }

    .ability-btn {
      width: 72px;
      height: 72px;
      border-radius: 12px;
      background: linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0.01));
      border: 1px solid rgba(255,255,255,0.05);
      display:flex;
      align-items:center;
      justify-content:center;
      position:relative;
      overflow:hidden;
      box-shadow: 0 6px 18px rgba(2,6,23,0.45);
      cursor: pointer;
    }

    .ability-key {
      position:absolute;
      right:6px;
      bottom:6px;
      font-size:11px;
      color:rgba(255,255,255,0.8);
      background: rgba(0,0,0,0.18);
      border-radius:6px;
      padding:2px 6px;
      backdrop-filter: blur(4px);
    }

    .icon-canvas { width:44px; height:44px; image-rendering: pixelated; display:block; }

    .soft-btn {
      background: linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0.01));
      border: 1px solid rgba(255,255,255,0.04);
      padding: 8px 12px;
      border-radius: 10px;
      cursor: pointer;
    }

    .tooltip {
      background: rgba(7,10,26,0.8);
      border: 1px solid rgba(255,255,255,0.06);
      padding: 8px 10px;
      border-radius: 8px;
      font-size: 13px;
      color: #e6eef8;
      box-shadow: 0 8px 20px rgba(2,6,23,0.6);
      position: absolute;
      z-index: 60;
      pointer-events: none;
      transform-origin: left top;
    }

    .game-canvas { border-radius: 10px; display:block; background: linear-gradient(180deg,#081223, #06101a); }

    .thin-scroll::-webkit-scrollbar { height:8px; width:8px; }
    .thin-scroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.06); border-radius: 999px; }
    .thin-scroll::-webkit-scrollbar-track { background: transparent; }

    .pixel-portrait { width:64px; height:64px; image-rendering: pixelated; border-radius:8px; background:linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0.01)); border:1px solid rgba(255,255,255,0.04); padding:6px; display:flex; align-items:center; justify-content:center; }

    /* small cooldown text on ability */
    .ability-cost {
      position:absolute;
      left:6px;
      top:6px;
      background: rgba(0,0,0,0.16);
      padding: 2px 6px;
      border-radius: 6px;
      font-size:11px;
      color:#ffd580;
      border:1px solid rgba(255,255,255,0.03);
    }

    .hpbar, .mpbar {
      height:12px;
      border-radius:30px;
      background: rgba(255,255,255,0.03);
      border:1px solid rgba(255,255,255,0.04);
      overflow:hidden;
    }

  </style>
</head>
<body class="p-6">

  <div class="max-w-7xl mx-auto">
    <header class="flex items-center justify-between mb-6">
      <div class="flex items-center gap-4">
        <div class="w-14 h-14 rounded-xl flex items-center justify-center bg-gradient-to-br from-indigo-500 to-pink-500 shadow-lg">
          <svg width="36" height="36" viewBox="0 0 24 24" fill="none" class="opacity-95">
            <path d="M12 2 L15.5 8.5 L22 10 L17 14 L18.5 21 L12 17.5 L5.5 21 L7 14 L2 10 L8.5 8.5 L12 2 Z" fill="white" opacity="0.92"/>
          </svg>
        </div>
        <div>
          <div class="text-2xl fancy-title">Arcane Skirmish</div>
          <div class="text-xs text-slate-300 mt-0.5">像素风强化 · 角色技能与动画</div>
        </div>
      </div>

      <div class="flex items-center gap-4">
        <div class="badge flex items-center gap-2">
          <svg width="16" height="16" viewBox="0 0 24 24" class="opacity-80"><path d="M12 2l3 7 7 1-5 5 1 7-6-4-6 4 1-7-5-5 7-1 3-7z" fill="white"/></svg>
          冒险者
        </div>
        <div class="badge flex items-center gap-2">
          <svg width="16" height="16" viewBox="0 0 24 24" class="opacity-80"><path d="M3 12h18" stroke="white" stroke-width="2" stroke-linecap="round"/></svg>
          难度: 适中
        </div>
      </div>
    </header>

    <main class="grid grid-cols-12 gap-6">
      <!-- 游戏画面 -->
      <section class="col-span-8">
        <div class="glass p-4 relative overflow-hidden" style="min-height:640px;">
          <div class="absolute left-6 top-6 flex items-center gap-3">
            <div class="pixel-portrait glass p-1" title="角色像素头像">
              <canvas id="portrait" width="32" height="32" style="image-rendering:pixelated;"></canvas>
            </div>
            <div>
              <div class="text-sm text-slate-300">生命</div>
              <div class="mt-2 hpbar w-72">
                <div id="hpBar" class="h-3" style="width:100%; background: linear-gradient(90deg,#f87171,#fb7185);"></div>
              </div>

              <div class="mt-2 text-xs text-slate-400 flex items-center gap-3">
                <div id="hpText">120 / 120</div>
                <div id="levelBadge" class="ml-2 badge">LV 1</div>
                <div class="ml-2 text-xs text-amber-300 font-medium" id="xpText">XP: 0 / 50</div>
              </div>

              <div class="mt-3 text-sm text-slate-300">魔力</div>
              <div class="mt-2 mpbar w-72">
                <div id="mpBar" class="h-3" style="width:100%; background: linear-gradient(90deg,#60a5fa,#a78bfa);"></div>
              </div>
              <div class="mt-2 text-xs text-slate-400" id="mpText">MP: 100 / 100</div>
            </div>
          </div>

          <!-- Canvas container -->
          <div id="canvasWrap" class="mt-16 rounded-xl p-3 flex items-center justify-center relative">
            <canvas id="gameCanvas" width="1024" height="640" class="game-canvas rounded-lg"></canvas>
            <div id="crosshair" style="position:absolute; width:36px; height:36px; left:50%; top:50%; transform:translate(-50%,-50%); pointer-events:none; mix-blend-mode:screen;">
              <svg width="36" height="36" viewBox="0 0 40 40">
                <circle cx="20" cy="20" r="8" stroke="rgba(255,255,255,0.12)" stroke-width="2" fill="none"/>
                <circle cx="20" cy="20" r="3" fill="rgba(255,180,100,0.95)"/>
              </svg>
            </div>
            <div id="hitMarker" style="position:absolute; left:calc(50% - 8px); top:calc(50% - 8px); width:16px; height:16px; pointer-events:none; opacity:0;">
              <svg width="16" height="16" viewBox="0 0 24 24">
                <path d="M12 2 L15 10 L23 12 L15 14 L12 22 L9 14 L1 12 L9 10 L12 2 Z" fill="#fb7185" />
              </svg>
            </div>
          </div>

          <div class="absolute left-6 bottom-6 flex flex-col gap-3">
            <div id="log0" class="tooltip">控制：WASD · 鼠标瞄准 · 1 斩击 · 2 火球 · 3 影移 · 4 治疗波</div>
            <div id="log1" class="tooltip">像素风角色动画 · 技能有消耗与冷却</div>
          </div>
        </div>
      </section>

      <!-- 侧边面板 -->
      <aside class="col-span-4 flex flex-col gap-6">
        <div class="glass p-4">
          <div class="flex items-center justify-between">
            <div>
              <div class="text-sm text-slate-300">状态</div>
              <div class="text-lg font-semibold mt-1">夜行者 · <span id="playerName" class="text-amber-200">无名者</span></div>
            </div>
            <div class="text-right">
              <div id="goldBadge" class="badge">金币 0</div>
              <div class="text-xs text-slate-400 mt-2">等级: <span id="levelSmall">1</span></div>
            </div>
          </div>

          <div class="mt-4 grid grid-cols-2 gap-3">
            <div class="soft-btn flex items-center gap-3" id="btnHeal">
              <svg width="20" height="20" viewBox="0 0 24 24"><path d="M12 2v6M12 22v-6M2 12h6M22 12h-6" stroke="white" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" fill="none" /></svg>
              <div class="text-left">
                <div class="text-sm font-medium">急救包</div>
                <div class="text-xs text-slate-300">冷却 30s</div>
              </div>
            </div>

            <div class="soft-btn flex items-center gap-3 justify-between">
              <div class="text-left">
                <div class="text-sm font-medium">任务</div>
                <div class="text-xs text-slate-300">清理周边怪物 (剩余 <span id="questRemain">6</span>)</div>
              </div>
              <div class="ml-2">
                <svg width="22" height="22" viewBox="0 0 24 24"><path d="M12 2v10l3 3" stroke="#ffd580" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" fill="none"></path></svg>
              </div>
            </div>
          </div>
        </div>

        <!-- abilities -->
        <div class="glass p-4">
          <div class="flex items-center justify-between">
            <div>
              <div class="text-sm text-slate-300">技能</div>
              <div class="text-lg font-semibold mt-1">技能面板</div>
            </div>
            <div class="text-xs text-slate-400">快捷键 1 / 2 / 3 / 4</div>
          </div>

          <div class="mt-4 flex items-center gap-3">
            <div class="ability-btn glow" id="ability-1" title="斩击 (1)">
              <canvas class="icon-canvas" data-icon="atk" width="16" height="16"></canvas>
              <div class="cd-overlay" data-ability="atk"></div>
              <div class="ability-key">1</div>
              <div class="ability-cost">0</div>
            </div>

            <div class="ability-btn" id="ability-2" title="火球 (2)">
              <canvas class="icon-canvas" data-icon="fireball" width="16" height="16"></canvas>
              <div class="cd-overlay" data-ability="fireball"></div>
              <div class="ability-key">2</div>
              <div class="ability-cost">20</div>
            </div>

            <div class="ability-btn" id="ability-3" title="影移 (3)">
              <canvas class="icon-canvas" data-icon="dash" width="16" height="16"></canvas>
              <div class="cd-overlay" data-ability="dash"></div>
              <div class="ability-key">3</div>
              <div class="ability-cost">22</div>
            </div>

            <div class="ability-btn" id="ability-4" title="治疗波 (4)">
              <canvas class="icon-canvas" data-icon="heal" width="16" height="16"></canvas>
              <div class="cd-overlay" data-ability="heal"></div>
              <div class="ability-key">4</div>
              <div class="ability-cost">48</div>
            </div>

          </div>
        </div>

        <!-- 小地图与背包 -->
        <div class="glass p-4 flex gap-3">
          <div class="w-1/2 flex flex-col gap-2">
            <div class="text-sm text-slate-300">小地图</div>
            <canvas id="miniMap" width="220" height="140" class="rounded-md"></canvas>
          </div>

          <div class="w-1/2">
            <div class="text-sm text-slate-300">背包</div>
            <div class="grid grid-cols-3 gap-2 mt-2">
              <div class="glass p-2 flex items-center justify-center">空</div>
              <div class="glass p-2 flex items-center justify-center">药</div>
              <div class="glass p-2 flex items-center justify-center">宝石</div>
            </div>

            <div class="mt-3 text-xs text-slate-400">点击物品可使用/查看</div>
          </div>
        </div>

        <!-- 控制面板 -->
        <div class="glass p-4 flex items-center justify-between">
          <div class="flex items-center gap-3">
            <button id="btnReset" class="soft-btn">重置场景</button>
            <button id="btnHelp" class="soft-btn">帮助</button>
          </div>

          <div class="text-xs text-slate-400">演示版 · 无联网资源</div>
        </div>
      </aside>
    </main>
  </div>

  <!-- 自定义 tooltip 容器 -->
  <div id="skillTooltip" style="position:fixed; display:none;"></div>

  <!-- 游戏脚本 -->
  <script>
  /*
    Arcane Skirmish — Pixel Upgrade
    - 在上一版基础上增强：像素风角色/敌人、角色动画、更多技能（4个技能）、魔力系统与像素化图标
    - 单文件实现，全部绘制并使用内部像素画生成
  */

  // --- 工具 ---
  const rand = (a,b)=> a + Math.random()*(b-a);
  const clamp = (v,a,b)=> Math.max(a, Math.min(b, v));
  const dist = (ax,ay,bx,by)=> Math.hypot(ax-bx, ay-by);
  const dist2 = (ax,ay,bx,by)=> {
    const dx=ax-bx, dy=ay-by; return dx*dx + dy*dy;
  };
  const now = ()=> performance.now()/1000;

  // Canvas & HiDPI
  const canvas = document.getElementById('gameCanvas');
  const ctx = canvas.getContext('2d');

  function setupDPR() {
    const dpr = window.devicePixelRatio || 1;
    const w = canvas.width; const h = canvas.height;
    canvas.width = w * dpr;
    canvas.height = h * dpr;
    canvas.style.width = w + "px";
    canvas.style.height = h + "px";
    ctx.setTransform(dpr,0,0,dpr,0,0);
  }
  setupDPR();

  const mini = document.getElementById('miniMap');
  const mctx = mini.getContext('2d');

  // World
  const WORLD = { W: 2000, H: 1400 };

  // Player
  const player = {
    x: WORLD.W/2, y: WORLD.H/2,
    vx:0, vy:0, size:16,
    speed: 260,
    maxHp: 140, hp:140,
    maxMp: 120, mp:120, mpRegen: 8,
    atk: 32, def:6, crit:0.09,
    level:1, xp:0, xpToLevel:50,
    gold:0,
    invulnerable:false,
    dashTime:0,
    anim: { type:'idle', start: now(), dur: 0 },
    portraitColors: { hair:'#f97316', cloth:'#7c3aed', skin:'#ffd8b3' }
  };

  // Entities
  let enemies = [];
  let projectiles = [];
  let particles = [];
  let floatingTexts = [];
  let effects = []; // generic visual effects (slash, heal wave)
  let afterimages = [];

  // sprites collection (filled later)
  const SPRITES = { player: null, enemy: {}, icons: {} };

  // Input
  const keys = {};
  let mouse = { x: canvas.width/2, y: canvas.height/2, worldX: player.x, worldY: player.y, down:false };

  window.addEventListener('keydown', (e) => {
    keys[e.key.toLowerCase()] = true;
    if (['1','2','3','4'].includes(e.key)) {
      const id = e.key === '1' ? 'atk' : (e.key === '2' ? 'fireball' : (e.key === '3' ? 'dash' : 'heal'));
      castAbility(id);
    }
  });
  window.addEventListener('keyup', (e) => {
    keys[e.key.toLowerCase()] = false;
  });

  // Mouse
  canvas.addEventListener('mousemove', (e)=>{
    const rect = canvas.getBoundingClientRect();
    const x = (e.clientX - rect.left);
    const y = (e.clientY - rect.top);
    mouse.x = x; mouse.y = y;
    mouse.worldX = camera.x + x;
    mouse.worldY = camera.y + y;
  });
  canvas.addEventListener('mousedown', (e)=>{
    mouse.down = true;
    castAbility('atk');
  });
  canvas.addEventListener('mouseup', (e)=> mouse.down=false);

  // Camera
  const camera = { x: player.x - canvas.width/2, y: player.y - canvas.height/2, shake:0 };

  // Abilities
  const abilities = {
    atk: { id:'atk', key:'1', name:'斩击', cd:0.45, last:-99, cost:0, desc:'近战斩击，短距离切割敌人。' },
    fireball: { id:'fireball', key:'2', name:'火球', cd:5.2, last:-99, cost:20, desc:'发射火球，命中并造成爆炸伤害。' },
    dash: { id:'dash', key:'3', name:'影移', cd:3.8, last:-99, cost:22, desc:'快速位移并短暂无敌，留下残影造成伤害。' },
    heal: { id:'heal', key:'4', name:'治疗波', cd:20, last:-99, cost:48, desc:'释放恢复波，立即回复生命并对周围敌人造成法术伤害。' }
  };

  const cdOverlays = {
    atk: document.querySelector('.cd-overlay[data-ability="atk"]'),
    fireball: document.querySelector('.cd-overlay[data-ability="fireball"]'),
    dash: document.querySelector('.cd-overlay[data-ability="dash"]'),
    heal: document.querySelector('.cd-overlay[data-ability="heal"]')
  };

  // UI elements
  const hpBar = document.getElementById('hpBar');
  const mpBar = document.getElementById('mpBar');
  const hpText = document.getElementById('hpText');
  const mpText = document.getElementById('mpText');
  const xpText = document.getElementById('xpText');
  const levelBadge = document.getElementById('levelBadge');
  const levelSmall = document.getElementById('levelSmall');
  const goldBadge = document.getElementById('goldBadge');
  const questRemain = document.getElementById('questRemain');

  // small portrait canvas
  const portraitCanvas = document.getElementById('portrait');
  const pctx = portraitCanvas.getContext('2d');

  // Build sprites (pixel art generated procedurally)
  (function buildSprites(){
    const base = 16; // base sprite size
    const iconSize = 16;

    // helper to create small canvas
    function mk(w=base,h=base){
      const c = document.createElement('canvas');
      c.width = w; c.height = h;
      return c;
    }

    // draw a low-res character onto ctx (base size grid)
    function drawPlayerLow(ctx, variant='idle', frame=0, palette = player.portraitColors) {
      // clear
      ctx.clearRect(0,0,base,base);
      // alias for convenience
      const hair = palette.hair; const cloth = palette.cloth; const skin = palette.skin;
      const darkHair = shade(hair, -18);
      const darkCloth = shade(cloth, -18);

      // head/hair
      // hair top
      ctx.fillStyle = hair; ctx.fillRect(4,0,8,3);
      // side locks
      ctx.fillRect(3,2,2,3); ctx.fillRect(11,2,2,3);
      // face
      ctx.fillStyle = skin; ctx.fillRect(5,3,6,4);
      // eyes
      ctx.fillStyle = '#111'; ctx.fillRect(6,4,1,1); ctx.fillRect(9,4,1,1);

      // neck
      ctx.fillStyle = skin; ctx.fillRect(7,7,2,1);

      // body
      ctx.fillStyle = cloth; ctx.fillRect(4,8,8,5);
      // collar or highlight
      ctx.fillStyle = darkCloth; ctx.fillRect(4,8,8,1);

      // arms with slight animation offsets
      const armOffset = (variant==='walk') ? ((frame % 2 === 0) ? -1 : 1) : 0;
      ctx.fillStyle = cloth;
      ctx.fillRect(2,9 + (armOffset>0?1:0),2,3);
      ctx.fillRect(12,9 - (armOffset>0?1:0),2,3);

      // legs & boots - walking frames move
      let leftLegY = 13; let rightLegY = 13;
      if (variant === 'walk') {
        if (frame % 4 === 0) { leftLegY = 12; rightLegY = 14; }
        if (frame % 4 === 1) { leftLegY = 13; rightLegY = 13; }
        if (frame % 4 === 2) { leftLegY = 14; rightLegY = 12; }
        if (frame % 4 === 3) { leftLegY = 13; rightLegY = 13; }
      }
      ctx.fillStyle = '#1f2937';
      ctx.fillRect(5, leftLegY, 2, 2);
      ctx.fillRect(9, rightLegY, 2, 2);

      // small belt highlight
      ctx.fillStyle = '#ffd580';
      ctx.fillRect(7,10,2,1);

      // outline pixels for contrast (simple)
      ctx.fillStyle = '#00000044';
      ctx.globalAlpha = 0.18;
      ctx.fillRect(4,8,8,5);
      ctx.globalAlpha = 1;
    }

    // simple enemy pixel variants
    function makeSlime(baseColor='#34d399') {
      const c = mk(base, base);
      const g = c.getContext('2d');
      // draw blob
      g.fillStyle = baseColor;
      g.fillRect(4,6,8,6);
      g.fillRect(5,5,6,1);
      g.fillStyle = shade(baseColor,-14);
      g.fillRect(4,6,8,1);
      // eyes
      g.fillStyle='#08111a';
      g.fillRect(6,8,1,1);
      g.fillRect(9,8,1,1);
      return c;
    }

    function makeBat(baseColor='#f97316') {
      const c = mk(base, base);
      const g = c.getContext('2d');
      g.fillStyle = baseColor;
      // head
      g.fillRect(6,5,4,3);
      // wings
      g.fillRect(2,7,3,1);
      g.fillRect(11,7,3,1);
      g.fillRect(3,8,2,1);
      g.fillRect(12,8,2,1);
      // eyes
      g.fillStyle='#000';
      g.fillRect(7,6,1,1);
      g.fillRect(8,6,1,1);
      return c;
    }

    // icon generators (tiny pixel glyphs)
    function iconSlash() {
      const c = mk(iconSize, iconSize);
      const g = c.getContext('2d');
      g.fillStyle = '#ffd1d1';
      g.fillRect(3,2,2,8);
      g.fillStyle = '#fff1a8';
      g.fillRect(5,3,2,6);
      g.fillStyle = '#fb7185';
      g.fillRect(7,5,3,2);
      return c;
    }
    function iconFire() {
      const c = mk(iconSize,iconSize);
      const g = c.getContext('2d');
      g.fillStyle='#ffb86b'; g.fillRect(6,2,4,4);
      g.fillStyle='#fb7185'; g.fillRect(5,4,6,3);
      g.fillStyle='#fff5d6'; g.fillRect(7,3,2,2);
      return c;
    }
    function iconDash() {
      const c = mk(iconSize,iconSize);
      const g = c.getContext('2d');
      g.fillStyle='#93c5fd'; g.fillRect(3,5,10,3);
      g.fillStyle='#60a5fa'; g.fillRect(9,4,2,1);
      g.fillStyle='#fff'; g.fillRect(4,6,4,1);
      return c;
    }
    function iconHeal() {
      const c = mk(iconSize,iconSize);
      const g = c.getContext('2d');
      g.fillStyle='#a5b4fc'; g.fillRect(5,2,6,6);
      g.fillStyle='#fff'; g.fillRect(7,4,2,1);
      g.fillRect(7,3,1,4);
      return c;
    }

    // helper to shade hex color (simple)
    function shade(hex, percent) {
      // hex like #rrggbb
      const v = hex.substring(1);
      const r = parseInt(v.substr(0,2),16);
      const g = parseInt(v.substr(2,2),16);
      const b = parseInt(v.substr(4,2),16);
      const f = (n)=> clamp(Math.round(n + (percent/100)*255),0,255);
      return '#' + [f(r).toString(16).padStart(2,'0'), f(g).toString(16).padStart(2,'0'), f(b).toString(16).padStart(2,'0')].join('');
    }

    // build player frames: idle(2) walk(4) attack(3) dash(2)
    const playerFrames = { idle: [], walk: [], attack: [], dash: [] };
    // idle 2 frames
    for (let f=0; f<2; f++) {
      const c = mk(base, base);
      drawPlayerLow(c.getContext('2d'), 'idle', f);
      playerFrames.idle.push(c);
    }
    // walk 4
    for (let f=0; f<4; f++) {
      const c= mk(base, base);
      drawPlayerLow(c.getContext('2d'), 'walk', f);
      playerFrames.walk.push(c);
    }
    // attack frames (we will draw slight forward lean + sword pixel)
    for (let f=0; f<3; f++) {
      const c = mk(base, base);
      const g = c.getContext('2d');
      drawPlayerLow(g, 'idle', 0);
      // add sword forward (on right side)
      g.fillStyle = '#fff';
      if (f===0) g.fillRect(11,7,2,1);
      if (f===1) { g.fillRect(11,6,3,1); g.fillRect(12,7,1,1); }
      if (f===2) { g.fillRect(11,5,4,1); g.fillRect(13,6,1,1); }
      playerFrames.attack.push(c);
    }
    // dash frames (lean forward)
    for (let f=0; f<2; f++) {
      const c = mk(base, base);
      const g = c.getContext('2d');
      drawPlayerLow(g, 'idle', 0);
      // lean: move head one pixel forward by redrawing offseted
      g.fillStyle = shade(player.portraitColors.hair,-8);
      g.fillRect(4,0,8,3);
      playerFrames.dash.push(c);
    }

    SPRITES.player = { baseSize: base, frames: playerFrames, scale: 2 };

    // enemies
    SPRITES.enemy.slime = makeSlime('#34d399');
    SPRITES.enemy.bat = makeBat('#f97316');

    // icons
    SPRITES.icons.atk = iconSlash();
    SPRITES.icons.fireball = iconFire();
    SPRITES.icons.dash = iconDash();
    SPRITES.icons.heal = iconHeal();

    // draw ability icons into DOM canvases
    const iconCanvases = document.querySelectorAll('.icon-canvas');
    iconCanvases.forEach((c)=>{
      const key = c.dataset.icon;
      const src = SPRITES.icons[key];
      const ctx2 = c.getContext('2d');
      // scale drawn onto 16x16 canvas for crisp pixels, then CSS scales to 44x44
      ctx2.imageSmoothingEnabled = false;
      ctx2.clearRect(0,0,c.width,c.height);
      if (src) ctx2.drawImage(src, 0, 0, c.width, c.height);
      else {
        ctx2.fillStyle = '#fff';
        ctx2.fillRect(2,2,12,12);
      }
    });

    // draw portrait
    function drawPortrait() {
      pctx.imageSmoothingEnabled = false;
      pctx.clearRect(0,0,portraitCanvas.width, portraitCanvas.height);
      // draw the first idle frame scaled to 32x32
      const f = playerFrames.idle[0];
      pctx.drawImage(f, 0, 0, portraitCanvas.width, portraitCanvas.height);
    }
    drawPortrait();

  })();

  // spawn initial enemies
  let enemyId = 1;
  function spawnEnemy(type, x, y) {
    type = type || (Math.random() < 0.55 ? 'slime' : 'bat');
    const e = {
      id: enemyId++,
      type,
      x: x || rand(80, WORLD.W-80),
      y: y || rand(80, WORLD.H-80),
      vx:0, vy:0,
      size: (type==='slime'?14:12),
      color: (type==='slime'?'#34d399':'#f97316'),
      maxHp: (type==='slime'?44:32),
      hp: (type==='slime'?44:32),
      speed: (type==='slime'?66:120),
      attack: (type==='slime'?7:5),
      detectRange: (type==='slime'?320:420),
      attackRange: 22,
      attackCooldown: 1.2,
      lastAttack: 0,
      gold: (type==='slime'?8:5),
      xp: (type==='slime'?14:9),
      animTime: now()
    };
    enemies.push(e);
  }

  for (let i=0;i<8;i++) spawnEnemy(Math.random()<0.6?'slime':'bat');

  // projectiles structure: {x,y,vx,vy,radius,damage,owner,type,life}
  function createProjectile(x,y,angle,speed,damage,radius,owner,life=3,type='bullet') {
    projectiles.push({ x,y,vx:Math.cos(angle)*speed, vy:Math.sin(angle)*speed, damage, radius, owner, life, type, born: now() });
  }

  // particles and floating text
  function spawnParticles(x,y,color,count=8) {
    for (let i=0;i<count;i++){
      const ang = Math.random()*Math.PI*2;
      const sp = rand(60,260);
      particles.push({
        x,y,
        vx:Math.cos(ang)*sp,
        vy:Math.sin(ang)*sp,
        r: rand(1.2,3.8),
        life: rand(0.36,0.92),
        born: now(),
        color,
        alpha:1
      });
    }
  }
  function showFloatingText(text,x,y,color='#fff',size=14,life=1.0) {
    floatingTexts.push({ text, x, y, color, size, born:now(), life });
  }

  // visual effects (slash, heal wave)
  function createEffect(type, opts) {
    // type: 'slash', 'healWave', 'afterimage'
    effects.push(Object.assign({ type, born: now() }, opts));
  }

  // ability logic
  function canCast(id) {
    const a = abilities[id];
    if (!a) return false;
    const t = now();
    if (t - a.last < a.cd) return false;
    if (player.mp < a.cost) return false;
    return true;
  }

  function castAbility(id) {
    const a = abilities[id];
    if (!a) return;
    const t = now();
    // cooldown check
    if (t - a.last < a.cd) {
      // small UI pulse
      const el = cdOverlays[id] && cdOverlays[id].parentElement;
      if (el) el.animate([{ transform:'scale(1)' }, { transform:'scale(1.06)' }, { transform:'scale(1)' }], { duration:220 });
      return;
    }
    if (player.mp < a.cost) {
      showFloatingText('魔力不足', player.x, player.y-22, '#93c5fd');
      return;
    }
    // consume
    a.last = t;
    player.mp = Math.max(0, player.mp - a.cost);

    // perform effect
    if (id === 'atk') {
      // melee cone attack
      doMeleeAttack();
      player.anim = { type:'attack', start: now(), dur: 0.26 };
      // small recoil
      camera.shake = 6;
    } else if (id === 'fireball') {
      const angle = Math.atan2(mouse.worldY - player.y, mouse.worldX - player.x);
      createProjectile(player.x + Math.cos(angle)*18, player.y + Math.sin(angle)*18, angle, 380, Math.round(player.atk*1.95), 8, 'player', 2.2, 'fireball');
      spawnParticles(player.x + Math.cos(angle)*18, player.y + Math.sin(angle)*18, '#ffb86b', 10);
      player.anim = { type:'cast', start: now(), dur: 0.32 };
      camera.shake = 8;
    } else if (id === 'dash') {
      const angle = Math.atan2(mouse.worldY - player.y, mouse.worldX - player.x);
      player.vx = Math.cos(angle)*1200;
      player.vy = Math.sin(angle)*1200;
      player.dashTime = 0.18;
      player.invulnerable = true;
      // spawn afterimages
      for (let i=0;i<6;i++){
        afterimages.push({ x: player.x, y: player.y, frame: pickPlayerFrame(), alpha: 0.9 - i*0.12, life: 0.26 + i*0.02, born: now() - i*0.02 });
      }
      player.anim = { type:'dash', start: now(), dur: 0.18 };
      camera.shake = 9;
    } else if (id === 'heal') {
      // heal and AoE damage
      const healAmount = Math.round(player.maxHp * 0.36) + 12;
      player.hp = Math.min(player.maxHp, player.hp + healAmount);
      // damage nearby enemies
      const radius = 110;
      for (let i = enemies.length - 1; i >= 0; i--) {
        const e = enemies[i];
        if (dist(e.x, e.y, player.x, player.y) < radius) {
          const dmg = Math.round(player.atk * 0.9);
          e.hp -= dmg;
          showFloatingText('-' + dmg, e.x, e.y-6, '#ffd1d1');
          spawnParticles(e.x, e.y, '#ff9a5b', 8);
          if (e.hp <= 0) {
            handleEnemyDeath(e, player);
            enemies.splice(i,1);
          }
        }
      }
      createEffect('healWave', { x: player.x, y: player.y, radius, life: 0.9 });
      player.anim = { type:'cast', start: now(), dur: 0.44 };
      camera.shake = 6;
    }
  }

  // melee implementation
  function doMeleeAttack() {
    const range = 44;
    const angle = Math.atan2(mouse.worldY - player.y, mouse.worldX - player.x);
    const arc = Math.PI * 0.8; // wide arc
    createEffect('slash', { x: player.x + Math.cos(angle)*18, y: player.y + Math.sin(angle)*18, angle, life:0.26, size:72 });
    for (let i = enemies.length - 1; i >= 0; i--) {
      const e = enemies[i];
      const d = dist(e.x, e.y, player.x, player.y);
      if (d <= range + e.size) {
        const angToE = Math.atan2(e.y - player.y, e.x - player.x);
        let da = Math.abs(normalizeAngle(angToE - angle));
        if (da < arc/2) {
          // hit
          let dmg = player.atk;
          if (Math.random() < player.crit) {
            dmg = Math.round(dmg * 1.6);
            showFloatingText('暴击 ' + dmg, e.x, e.y-8, '#ffdd99');
          } else {
            showFloatingText('-' + dmg, e.x, e.y-8, '#ffd1d1');
          }
          e.hp -= dmg;
          spawnParticles(e.x, e.y, '#fb7185', 10);
          camera.shake = 4;
          if (e.hp <= 0) {
            handleEnemyDeath(e, player);
            enemies.splice(i,1);
          }
        }
      }
    }
  }

  function normalizeAngle(a) {
    while (a < -Math.PI) a += Math.PI*2;
    while (a > Math.PI) a -= Math.PI*2;
    return a;
  }

  // handle enemy death
  function handleEnemyDeath(e, killer) {
    spawnParticles(e.x, e.y, '#fca5a5', 16);
    showFloatingText('+' + e.xp + ' XP', e.x, e.y - 8, '#ffd580');
    player.xp += e.xp;
    player.gold += e.gold;
    // respawn chance
    if (Math.random() < 0.5) spawnEnemy(undefined, rand(60,WORLD.W-60), rand(60,WORLD.H-60));
  }

  function checkLevelUp() {
    while (player.xp >= player.xpToLevel) {
      player.xp -= player.xpToLevel;
      player.level += 1;
      player.maxHp += 20;
      player.atk += 6;
      player.hp = player.maxHp;
      player.xpToLevel = Math.round(player.xpToLevel * 1.5);
      showFloatingText('升级了！LV ' + player.level, player.x, player.y - 36, '#a78bfa', 18, 2.0);
      flashScreen();
      tryBeep(780, 0.14);
    }
  }

  // screen flash & audio
  let flashAlpha = 0;
  function flashScreen() { flashAlpha = 0.6; }
  function tryBeep(freq = 440, time = 0.12) {
    try {
      const ctxA = new (window.AudioContext || window.webkitAudioContext)();
      const o = ctxA.createOscillator();
      const g = ctxA.createGain();
      o.type = 'sine'; o.frequency.value = freq;
      g.gain.value = 0.0001;
      o.connect(g); g.connect(ctxA.destination);
      o.start();
      g.gain.exponentialRampToValueAtTime(0.08, ctxA.currentTime + 0.02);
      g.gain.exponentialRampToValueAtTime(0.0001, ctxA.currentTime + time);
      o.stop(ctxA.currentTime + time + 0.02);
    } catch (e) {}
  }

  // simple sound cue on hit (non-blocking)
  function hitSound() { tryBeep(680, 0.06); }

  // update loop
  let lastFrame = performance.now();
  function update(dt) {
    // cooldown overlays
    for (const k in abilities) {
      const a = abilities[k];
      const elapsed = now() - a.last;
      const pct = clamp(elapsed / a.cd, 0, 1);
      const deg = Math.round((1 - pct) * 360);
      const overlay = cdOverlays[k];
      if (overlay) overlay.style.background = `conic-gradient(rgba(0,0,0,0.58) ${deg}deg, transparent 0deg)`;
    }

    // mp regen
    player.mp = clamp(player.mp + player.mpRegen * dt, 0, player.maxMp);

    // movement input
    let moveX = 0, moveY = 0;
    if (keys['w']||keys['arrowup']) moveY -= 1;
    if (keys['s']||keys['arrowdown']) moveY += 1;
    if (keys['a']||keys['arrowleft']) moveX -= 1;
    if (keys['d']||keys['arrowright']) moveX += 1;
    const len = Math.hypot(moveX, moveY) || 1;
    if (player.dashTime <= 0) {
      player.vx += (moveX/len) * player.speed * dt * 8;
      player.vy += (moveY/len) * player.speed * dt * 8;
      player.vx *= Math.pow(0.86, dt*60);
      player.vy *= Math.pow(0.86, dt*60);
    } else {
      player.vx *= Math.pow(0.92, dt*60);
      player.vy *= Math.pow(0.92, dt*60);
    }
    player.x += player.vx * dt;
    player.y += player.vy * dt;
    player.x = clamp(player.x, 12, WORLD.W - 12);
    player.y = clamp(player.y, 12, WORLD.H - 12);

    if (player.dashTime > 0) {
      player.dashTime -= dt;
      if (player.dashTime <= 0) player.invulnerable = false;
    }

    // update projectiles
    for (let i = projectiles.length - 1; i >= 0; i--) {
      const p = projectiles[i];
      p.x += p.vx * dt; p.y += p.vy * dt;
      p.life -= dt;
      if (p.owner === 'player') {
        for (let j = enemies.length - 1; j >= 0; j--) {
          const e = enemies[j];
          if (dist2(p.x,p.y,e.x,e.y) <= (p.radius + e.size)*(p.radius+e.size)) {
            let dmg = p.damage;
            if (Math.random() < player.crit) { dmg = Math.round(dmg*1.6); showFloatingText('暴击 ' + dmg, e.x, e.y-6, '#ffdd99'); }
            else showFloatingText('-' + dmg, e.x, e.y-6, '#ffd1d1');
            e.hp -= dmg;
            spawnParticles(p.x, p.y, (p.type==='fireball'?'#ffb86b':'#fff'), 10);
            if (p.type === 'fireball') {
              for (const oe of enemies) {
                if (oe !== e && dist(oe.x, oe.y, p.x, p.y) < 36) {
                  const od = Math.round(dmg * 0.56);
                  oe.hp -= od;
                  showFloatingText('-' + od, oe.x, oe.y-6, '#ffd1d1');
                }
              }
            }
            projectiles.splice(i,1);
            hitSound();
            break;
          }
        }
      }
      if (p.life <= 0) {
        if (p.type === 'fireball') spawnParticles(p.x, p.y, '#ff9a5b', 18);
        const idx = projectiles.indexOf(p);
        if (idx >= 0) projectiles.splice(idx,1);
      }
    }

    // enemies AI
    for (let i = enemies.length - 1; i >= 0; i--) {
      const e = enemies[i];
      const d = dist(e.x, e.y, player.x, player.y);
      if (d < e.detectRange) {
        const ang = Math.atan2(player.y - e.y, player.x - e.x);
        e.vx += Math.cos(ang) * e.speed * 0.04;
        e.vy += Math.sin(ang) * e.speed * 0.04;
        const sp = Math.hypot(e.vx, e.vy);
        if (sp > e.speed) { e.vx = (e.vx/sp) * e.speed; e.vy = (e.vy/sp) * e.speed; }
      } else {
        e.vx *= 0.94; e.vy *= 0.94;
        if (Math.random() < dt*0.6) { e.vx += rand(-20,20); e.vy += rand(-20,20); }
      }
      e.x += e.vx * dt; e.y += e.vy * dt;
      e.x = clamp(e.x, 8, WORLD.W-8); e.y = clamp(e.y, 8, WORLD.H-8);

      if (d < e.attackRange + player.size && now() - e.lastAttack > e.attackCooldown) {
        if (!player.invulnerable) {
          const raw = e.attack;
          const dmg = Math.max(1, Math.round(raw * (1 - player.def / 100)));
          player.hp -= dmg;
          showFloatingText('-' + dmg, player.x, player.y - 12, '#ffadad');
          spawnParticles(player.x, player.y, '#ff6b6b', 8);
          camera.shake = 4;
        }
        e.lastAttack = now();
      }

      if (e.hp <= 0) {
        handleEnemyDeath(e, player);
        enemies.splice(i,1);
      }
    }

    // particles & floating texts
    for (let i = particles.length - 1; i >= 0; i--) {
      const p = particles[i];
      const age = now() - p.born;
      if (age > p.life) { particles.splice(i,1); continue; }
      p.x += p.vx * dt; p.y += p.vy * dt;
      p.vx *= Math.pow(0.92, dt*60); p.vy *= Math.pow(0.92, dt*60);
      p.alpha = 1 - (age / p.life);
    }
    for (let i = floatingTexts.length - 1; i >= 0; i--) {
      const f = floatingTexts[i];
      const age = now() - f.born;
      if (age > f.life) { floatingTexts.splice(i,1); continue; }
      f.y -= dt * 18;
    }

    // effects & afterimages
    for (let i = effects.length - 1; i >= 0; i--) {
      const ef = effects[i];
      if (now() - ef.born > ef.life) effects.splice(i,1);
    }
    for (let i = afterimages.length - 1; i >= 0; i--) {
      const a = afterimages[i];
      if (now() - a.born > a.life) afterimages.splice(i,1);
    }

    // collisions enemy touching player (bump)
    for (const e of enemies) {
      const d2p = dist2(e.x,e.y,player.x,player.y);
      const r = e.size + player.size;
      if (d2p < r*r && now() - e.lastAttack > e.attackCooldown) {
        if (!player.invulnerable) {
          const raw = e.attack;
          const dmg = Math.max(1, Math.round(raw * (1 - player.def / 100)));
          player.hp -= dmg;
          showFloatingText('-' + dmg, player.x, player.y - 16, '#ffadad');
          spawnParticles(player.x, player.y, '#ff6b6b', 5);
          camera.shake = 2;
        }
        e.lastAttack = now();
      }
    }

    // clamp player hp & respawn
    player.hp = clamp(player.hp, 0, player.maxHp);
    if (player.hp <= 0) {
      showFloatingText('你已阵亡，复活中...', player.x, player.y, '#f87171', 13, 1.8);
      player.hp = player.maxHp;
      player.x = WORLD.W/2 + rand(-120,120); player.y = WORLD.H/2 + rand(-120,120);
      player.vx = player.vy = 0;
    }

    checkLevelUp();

    // camera follow
    camera.x = player.x - canvas.width/2; camera.y = player.y - canvas.height/2;
    camera.x = clamp(camera.x, 0, WORLD.W - canvas.width);
    camera.y = clamp(camera.y, 0, WORLD.H - canvas.height);
    if (camera.shake > 0) { camera.shake -= dt*16; if (camera.shake < 0) camera.shake = 0; }

    flashAlpha = Math.max(0, flashAlpha - dt * 0.9);

    // update HUD texts
    hpBar.style.width = Math.round(player.hp / player.maxHp * 100) + '%';
    mpBar.style.width = Math.round(player.mp / player.maxMp * 100) + '%';
    hpText.innerText = Math.round(player.hp) + ' / ' + player.maxHp;
    mpText.innerText = 'MP: ' + Math.round(player.mp) + ' / ' + player.maxMp;
    xpText.innerText = 'XP: ' + Math.floor(player.xp) + ' / ' + player.xpToLevel;
    levelBadge.innerText = 'LV ' + player.level;
    levelSmall.innerText = player.level;
    goldBadge.innerText = '金币 ' + player.gold;
    questRemain.innerText = Math.max(0, enemies.length);
  }

  // drawing utilities
  function pickPlayerFrame() {
    // choose best frame canvas based on anim state & time
    const spr = SPRITES.player;
    const frames = spr.frames;
    const baseSize = spr.baseSize;
    const t = now();
    const anim = player.anim || { type:'idle', start:t, dur:0 };
    if (anim.type === 'attack') {
      const p = (t - anim.start) / anim.dur;
      const idx = Math.min(frames.attack.length - 1, Math.floor(p * frames.attack.length));
      return frames.attack[idx];
    }
    if (anim.type === 'dash') {
      const idx = Math.floor((t*14) % frames.dash.length);
      return frames.dash[idx];
    }
    // if moving (velocity)
    const speed = Math.hypot(player.vx, player.vy);
    if (speed > 18) {
      const idx = Math.floor(t * 10) % frames.walk.length;
      return frames.walk[idx];
    }
    // default idle
    const idx = Math.floor(t * 1.6) % frames.idle.length;
    return frames.idle[idx];
  }

  // draw main scene
  function draw() {
    // clear
    ctx.clearRect(0,0,canvas.width, canvas.height);

    // camera shake offsets
    const shakeX = (Math.random()-0.5) * camera.shake;
    const shakeY = (Math.random()-0.5) * camera.shake;

    ctx.save();
    ctx.translate(-camera.x + shakeX, -camera.y + shakeY);

    // background gradient
    const g = ctx.createLinearGradient(0,0,0,WORLD.H);
    g.addColorStop(0, '#071028'); g.addColorStop(1, '#04111a');
    ctx.fillStyle = g;
    ctx.fillRect(camera.x, camera.y, canvas.width, canvas.height);

    // soft decorative ellipses
    ctx.globalAlpha = 0.06;
    ctx.fillStyle = '#a78bfa';
    ctx.beginPath(); ctx.ellipse(camera.x + 120, camera.y + 80, 160, 60,0,0,Math.PI*2); ctx.fill();
    ctx.fillStyle = '#60a5fa';
    ctx.beginPath(); ctx.ellipse(camera.x + canvas.width - 260, camera.y + 40, 120, 50,0,0,Math.PI*2); ctx.fill();
    ctx.globalAlpha = 1;

    // ground grid
    drawGrid();

    // draw projectiles (pixel style)
    for (const p of projectiles) {
      if (p.type === 'fireball') {
        // draw pixel flame sprite (small)
        ctx.save();
        ctx.imageSmoothingEnabled = false;
        const size = p.radius * 2;
        const img = makeTinyFireSprite(); // returns small canvas
        ctx.drawImage(img, p.x - size/2, p.y - size/2, size, size);
        ctx.restore();
      } else {
        ctx.beginPath();
        ctx.fillStyle = '#fff'; ctx.arc(p.x, p.y, p.radius, 0, Math.PI*2); ctx.fill();
      }
    }

    // draw enemies (pixel sprites)
    for (const e of enemies) {
      // shadow
      ctx.beginPath();
      ctx.fillStyle = 'rgba(2,6,23,0.45)';
      ctx.ellipse(e.x, e.y + e.size*0.8, e.size*1.4, e.size*0.7, 0, 0, Math.PI*2);
      ctx.fill();

      // choose correct sprite
      const img = SPRITES.enemy[e.type] || SPRITES.enemy.slime;
      // draw pixelated scaled
      ctx.imageSmoothingEnabled = false;
      const scale = SPRITES.player.scale; // unify scale base
      const drawW = img.width * scale; const drawH = img.height * scale;
      ctx.drawImage(img, e.x - drawW/2, e.y - drawH/2, drawW, drawH);

      // health bar
      const healthW = e.size*2;
      const hpPct = clamp(e.hp / e.maxHp, 0, 1);
      ctx.fillStyle = 'rgba(255,255,255,0.06)'; ctx.fillRect(e.x - healthW/2, e.y - e.size - 14, healthW, 6);
      ctx.fillStyle = '#f87171'; ctx.fillRect(e.x - healthW/2, e.y - e.size - 14, healthW * hpPct, 6);
      ctx.strokeStyle = 'rgba(255,255,255,0.06)'; ctx.strokeRect(e.x - healthW/2, e.y - e.size - 14, healthW, 6);
    }

    // effects behind player (heal wave)
    for (const ef of effects) {
      if (ef.type === 'healWave') {
        const p = (now() - ef.born) / ef.life;
        const r = ef.radius * (0.6 + p);
        ctx.save();
        ctx.globalAlpha = 1 - p;
        ctx.beginPath();
        ctx.fillStyle = 'rgba(165,180,252,0.18)';
        ctx.arc(ef.x, ef.y, r, 0, Math.PI*2); ctx.fill();
        ctx.globalAlpha = 1;
        ctx.restore();
      }
    }

    // afterimages
    for (const a of afterimages) {
      ctx.save();
      ctx.globalAlpha = a.alpha * 0.9;
      ctx.imageSmoothingEnabled = false;
      const scale = SPRITES.player.scale;
      ctx.drawImage(a.frame, a.x - (a.frame.width*scale)/2, a.y - (a.frame.height*scale)/2, a.frame.width*scale, a.frame.height*scale);
      ctx.globalAlpha = 1;
      ctx.restore();
    }

    // draw player sprite (pixel)
    ctx.save();
    ctx.imageSmoothingEnabled = false;
    const frame = pickPlayerFrame();
    const flip = mouse.worldX < player.x;
    ctx.translate(player.x, player.y);
    if (flip) ctx.scale(-1,1);
    const scale = SPRITES.player.scale;
    ctx.drawImage(frame, -frame.width*scale/2, -frame.height*scale/2, frame.width*scale, frame.height*scale);
    ctx.restore();

    // slash effects (in front)
    for (const ef of effects) {
      if (ef.type === 'slash') {
        const p = (now() - ef.born) / ef.life;
        ctx.save();
        ctx.translate(ef.x, ef.y);
        ctx.rotate(ef.angle);
        ctx.globalAlpha = 1 - p;
        // pixelated slash: draw elongated rectangle with glow
        ctx.fillStyle = '#ffdecb';
        ctx.fillRect(-ef.size*0.35, -6, ef.size*0.7, 8);
        ctx.fillStyle = 'rgba(255,120,90,0.6)';
        ctx.fillRect(-ef.size*0.35, -4, ef.size*0.7, 5);
        ctx.globalAlpha = 1;
        ctx.restore();
      }
    }

    // particles
    for (const p of particles) {
      ctx.globalAlpha = p.alpha * 0.96;
      ctx.fillStyle = p.color;
      ctx.beginPath();
      ctx.arc(p.x, p.y, p.r, 0, Math.PI*2);
      ctx.fill();
      ctx.globalAlpha = 1;
    }

    // floating texts
    ctx.font = '600 14px Poppins';
    ctx.textAlign = 'center';
    for (const f of floatingTexts) {
      const age = now() - f.born;
      const alpha = clamp(1 - (age / f.life), 0, 1);
      ctx.globalAlpha = alpha;
      ctx.fillStyle = f.color;
      ctx.fillText(f.text, f.x, f.y);
      ctx.globalAlpha = 1;
    }

    ctx.restore();

    // screen flash
    if (flashAlpha > 0) {
      ctx.fillStyle = `rgba(200,160,255,${flashAlpha})`;
      ctx.fillRect(0,0,canvas.width,canvas.height);
    }

    // hit marker fade
    const hm = document.getElementById('hitMarker');
    if (hm && camera.shake > 0) {
      hm.style.opacity = clamp(camera.shake / 9, 0, 1);
    } else if (hm) hm.style.opacity = 0;
  }

  // ground grid
  function drawGrid() {
    const step = 48;
    ctx.save();
    const startX = Math.floor(camera.x/step) * step;
    const startY = Math.floor(camera.y/step) * step;
    ctx.globalAlpha = 0.12;
    ctx.strokeStyle = '#ffffff';
    for (let gx = startX; gx < camera.x + canvas.width; gx += step) {
      ctx.beginPath();
      ctx.moveTo(gx + 0.5, camera.y);
      ctx.lineTo(gx + 0.5, camera.y + canvas.height);
      ctx.stroke();
    }
    for (let gy = startY; gy < camera.y + canvas.height; gy += step) {
      ctx.beginPath();
      ctx.moveTo(camera.x, gy + 0.5);
      ctx.lineTo(camera.x + canvas.width, gy + 0.5);
      ctx.stroke();
    }
    ctx.globalAlpha = 1;
    ctx.restore();
  }

  // tiny fire sprite generator (returns canvas)
  function makeTinyFireSprite() {
    const c = document.createElement('canvas');
    c.width = 12; c.height = 12;
    const g = c.getContext('2d');
    g.imageSmoothingEnabled = false;
    // flame core
    g.fillStyle = '#fff3cf'; g.fillRect(5,2,2,2);
    g.fillStyle = '#ffb86b'; g.fillRect(4,4,4,4);
    g.fillStyle = '#fb7185'; g.fillRect(3,6,6,3);
    return c;
  }

  // main loop
  function loop() {
    const nowFrame = performance.now();
    let dt = (nowFrame - lastFrame) / 1000;
    if (dt > 0.06) dt = 0.06;
    lastFrame = nowFrame;
    update(dt);
    draw();
    drawMiniMap();
    requestAnimationFrame(loop);
  }
  requestAnimationFrame(loop);

  // minimap drawing
  function drawMiniMap(){
    const W = mini.width, H = mini.height;
    mctx.clearRect(0,0,W,H);
    mctx.fillStyle = '#071028';
    mctx.fillRect(0,0,W,H);
    const sx = W / WORLD.W, sy = H / WORLD.H;
    for (const e of enemies) {
      mctx.fillStyle = e.color;
      mctx.beginPath();
      mctx.arc(e.x * sx, e.y * sy, 3, 0, Math.PI*2);
      mctx.fill();
    }
    mctx.fillStyle = '#ffb86b';
    mctx.beginPath();
    mctx.arc(player.x * sx, player.y * sy, 4, 0, Math.PI*2);
    mctx.fill();
    mctx.strokeStyle = 'rgba(255,255,255,0.06)';
    mctx.strokeRect(camera.x * sx, camera.y * sy, canvas.width * sx, canvas.height * sy);
  }

  // UI bindings
  document.getElementById('ability-1').addEventListener('click', ()=> castAbility('atk'));
  document.getElementById('ability-2').addEventListener('click', ()=> castAbility('fireball'));
  document.getElementById('ability-3').addEventListener('click', ()=> castAbility('dash'));
  document.getElementById('ability-4').addEventListener('click', ()=> castAbility('heal'));

  document.getElementById('btnReset').addEventListener('click', ()=>{
    enemies = [];
    for (let i=0;i<8;i++) spawnEnemy(Math.random()<0.6?'slime':'bat');
    player.hp = player.maxHp; player.mp = player.maxMp; player.xp = 0; player.gold = 0;
    showFloatingText('场景已重置', player.x, player.y, '#bae6fd');
  });

  document.getElementById('btnHelp').addEventListener('click', ()=>{
    alert('操作提示：\n- WASD 移动，鼠标瞄准\n- 点击或按 1：斩击（近战）\n- 按 2：火球（远程爆炸）\n- 按 3：影移（短暂无敌并残影伤害）\n- 按 4：治疗波（回复并对周围敌人造成伤害）\n升级提升属性，击杀敌人获得经验与金币。');
  });

  // skill tooltip (custom)
  const tooltip = document.getElementById('skillTooltip');
  const abilityButtons = document.querySelectorAll('.ability-btn');
  abilityButtons.forEach(btn=>{
    const dat = btn.querySelector('.ability-key').innerText;
    const id = btn.id;
    let aid = null;
    if (id.endsWith('1') || btn.title.includes('斩击')) aid = 'atk';
    if (id.endsWith('2') || btn.title.includes('火球')) aid = 'fireball';
    if (id.endsWith('3') || btn.title.includes('影移')) aid = 'dash';
    if (id.endsWith('4') || btn.title.includes('治疗')) aid = 'heal';
    const a = abilities[aid];
    btn.addEventListener('mouseenter', (e)=>{
      if (!a) return;
      tooltip.style.display = 'block';
      tooltip.style.left = (e.clientX + 14) + 'px';
      tooltip.style.top = (e.clientY + 10) + 'px';
      tooltip.innerHTML = `<div class="tooltip"><div style="font-weight:700">${a.name} · (${a.key})</div><div style="font-size:13px; margin-top:6px;">${a.desc}</div><div style="margin-top:6px;font-size:12px;color:#ffd580">消耗 ${a.cost} MP · 冷却 ${a.cd}s</div></div>`;
    });
    btn.addEventListener('mousemove', (e)=>{
      tooltip.style.left = (e.clientX + 14) + 'px';
      tooltip.style.top = (e.clientY + 10) + 'px';
    });
    btn.addEventListener('mouseleave', ()=> tooltip.style.display = 'none');
  });

  // helper for tiny fire sprite caching (to avoid recreate each frame)
  const _tinyFireCache = makeTinyFireSprite();

  // small helpers used above in drawing to avoid recreation
  function makeTinyFireSprite() { // returns canvas
    // create and cache on first call
    if (makeTinyFireSprite._cache) return makeTinyFireSprite._cache;
    const c = document.createElement('canvas'); c.width = 12; c.height = 12;
    const g = c.getContext('2d'); g.imageSmoothingEnabled = false;
    g.fillStyle = '#fff3cf'; g.fillRect(5,2,2,2);
    g.fillStyle = '#ffb86b'; g.fillRect(4,4,4,4);
    g.fillStyle = '#fb7185'; g.fillRect(3,6,6,3);
    makeTinyFireSprite._cache = c;
    return c;
  }

  // utility: normalized angle difference (used earlier)
  // described already by normalizeAngle

  // initial ambient spawns
  setInterval(()=>{
    if (enemies.length < 6 && Math.random() < 0.6) spawnEnemy(Math.random()<0.5?'slime':'bat', rand(60,WORLD.W-60), rand(60,WORLD.H-60));
  }, 2600);

  // small UI animation for overlays: cost numbers update (in case mp changes)
  setInterval(()=>{
    const costEls = document.querySelectorAll('.ability-cost');
    costEls.forEach((el, idx) => {
      // map by order
      const costs = [abilities.atk.cost, abilities.fireball.cost, abilities.dash.cost, abilities.heal.cost];
      el.innerText = costs[idx];
    });
  }, 400);

  // responsive handler (no-op but keeps canvas consistent)
  window.addEventListener('resize', ()=> {
    // nothing essential as canvas has fixed internal size
  });

  </script>
</body>
</html>
